fix(ssh): resolve ProxyJump aliases recursively with per-hop auth (#347)#348
Merged
Conversation
When `ssh_host` (or a resolved alias) had `ProxyJump <alias>`, DBHub treated the jump alias as a literal hostname and reused the target's credentials for every hop — so `~/.ssh/config` setups that work with `ssh target` failed. - ssh-config-parser: add `resolveJumpHosts()` — resolve each ProxyJump hop that is a config alias through `parseSSHConfig` (its own HostName/User/Port/ IdentityFile), expand nested `ProxyJump` chains in connection order (`x -> a -> b`), and reject cycles. Non-alias tokens fall back to literal parsing (preserves explicit `ssh_proxy_jump` behavior). - ssh-tunnel: carry per-hop credentials in `establishChain` — each hop uses its own resolved key (extracted `loadPrivateKey` helper) instead of the target's, and a hop with its own key no longer inherits the target password. - manager: populate `SSHTunnelConfig.resolvedJumpHosts` from the resolved chain. - types: `JumpHost` gains `privateKey`/`passphrase`; `SSHTunnelConfig` gains `resolvedJumpHosts`. - tests: alias resolution (the issue's example), nested-chain ordering, cycle detection, literal passthrough, and token-user override. Out of scope (documented): ProxyCommand, `Match exec`, agent forwarding, and known_hosts policy — a system-`ssh` delegation mode for that residue is a separate decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates DBHub’s SSH tunnel setup to better match OpenSSH behavior when ssh_host (or its ProxyJump hops) are ~/.ssh/config aliases, by resolving jump-host aliases recursively and applying per-hop authentication data.
Changes:
- Add
resolveJumpHosts()to expandProxyJumptokens that are SSH config aliases into a fully-resolved, ordered hop chain (with cycle detection). - Update tunnel establishment to use
resolvedJumpHosts(when provided) and to apply per-hop private keys rather than reusing target credentials for all hops. - Plumb resolved jump-host chains through types, connector manager setup, and docs; add unit tests for jump-host resolution.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/ssh-tunnel.ts | Uses resolved jump-host chains and applies per-hop private key handling via loadPrivateKey(). |
| src/utils/ssh-config-parser.ts | Adds resolveJumpHosts() for recursive ProxyJump alias resolution and cycle detection. |
| src/utils/tests/ssh-config-parser.test.ts | Adds tests covering alias resolution, nested chains, cycle detection, and overrides. |
| src/types/ssh.ts | Extends JumpHost and SSHTunnelConfig to carry resolved per-hop auth details. |
| src/connectors/manager.ts | Computes resolvedJumpHosts during SSH tunnel config construction. |
| docs/config/command-line.mdx | Documents recursive ProxyJump alias resolution and per-hop auth behavior. |
…t hop port - establishChain: always offer the target password as a fallback for jump hops. Previously a hop was treated as "has its own key" whenever `privateKey` was set — but `findDefaultSSHKey` fills that even for an alias with only HostName/User, so password auth broke once the client had a default key. - resolveJumpHosts: detect an explicit `:port` from the raw ProxyJump token (incl. `:22`) so a token port overrides the alias's configured Port, instead of the `!== 22` heuristic that couldn't distinguish "no port" from ":22". - test: explicit `:port` (and `:22`) override the config Port; bare token uses it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot review on #348: the directive is `HostName` (capital N) in OpenSSH; fix the doc so copy/paste and search match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parseSSHConfig() required a username, so a jump-host alias defining only
HostName/Port/IdentityFile (relying on inheriting the user) returned null and
fell back to literal-hostname handling — diverging from OpenSSH.
- parseSSHConfig: add `{ requireUser = true }` option; top-level ssh_host
resolution still requires a user, but ProxyJump alias resolution passes
`requireUser: false` and lets the username be inherited from the target
(via `jumpHost.username || targetConfig.username` in establishChain).
- test: a jump alias with HostName/Port/IdentityFile but no User resolves
(host/port/key set, username undefined).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…348 review) - manager: wrap resolveJumpHosts() in the TUNNEL_ERROR_MARKER handling so a failure during jump-host resolution (e.g. cyclic ProxyJump) is classified as a tunnel error, consistent with tunnel.establish() failures. - ssh-config-parser: a host counts as "configured" if it sets any meaningful directive (HostName/User/Port/IdentityFile/ProxyJump), not only HostName/User. A jump alias defining just Port/IdentityFile/ProxyJump now resolves (HostName falls back to the alias) instead of being dropped to a literal hostname. - tests: jump alias with only Port/IdentityFile resolves with host = alias name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment on lines
+398
to
+411
| // Expand this alias's own jump chain first so it connects before the alias. | ||
| if (aliasConfig.proxyJump) { | ||
| resolved.push(...resolveJumpHosts(aliasConfig.proxyJump, configPath, new Set(visited).add(hop.host))); | ||
| } | ||
|
|
||
| resolved.push({ | ||
| host: aliasConfig.host, | ||
| // An explicit `:port` on the token wins; otherwise use the alias's Port (default 22). | ||
| port: tokenHasExplicitPort(token) ? hop.port : aliasConfig.port ?? 22, | ||
| // An explicit `user@` on the token wins; otherwise the alias's User. | ||
| username: hop.username ?? aliasConfig.username, | ||
| privateKey: aliasConfig.privateKey, | ||
| passphrase: aliasConfig.passphrase, | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #347.
Problem
When
ssh_host(or a resolved alias) hasProxyJump <alias>, DBHub diverged from nativesshin two ways, so configs that work withssh target-with-jumpfailed:parseSSHConfigcopiedProxyJumpas a raw string andparseJumpHostsparsed each token as a literal[user@]host[:port]— soProxyJump mybastionusedmybastionas a literal hostname instead of resolving its realHostName/User/Port/IdentityFile.establishChainreused the target's key/password/passphrase for every hop.Fix (self-contained — no new dependency)
ssh-config-parser: newresolveJumpHosts()— for each ProxyJump hop that is a config alias, resolve it viaparseSSHConfig(own HostName/User/Port/IdentityFile); expand nestedProxyJumpchains in connection order (x → a → b); reject cycles. Non-alias tokens (FQDN/IP) and aliases absent from config fall back to literal parsing, preserving explicitssh_proxy_jumpbehavior. Explicituser@/:porton a token overrides the config.ssh-tunnel:establishChainuses per-hop credentials — each hop loads its own resolved key (extracted aloadPrivateKeyhelper) and each hop prefers its own resolved key but the target password is always offered as a fallback.manager: populatesSSHTunnelConfig.resolvedJumpHostsfrom the resolved chain.JumpHostgainsprivateKey/passphrase;SSHTunnelConfiggainsresolvedJumpHosts.Scope / non-goals (documented)
ProxyCommand,Match exec, agent forwarding, andknown_hostspolicy are out of scope — a system-ssh(orssh -G) delegation mode for that exotic residue is a separate decision, kept out of this PR.Verification
pnpm build:backend— passes (DTS clean).resolveJumpHoststests pass: alias resolution (the issue's exact example), nested-chain ordering, cycle detection, literal passthrough, token-user override.mainand this branch on a dev machine that has real~/.sshkeys / DSN env vars —findDefaultSSHKeyadds aprivateKeythe old exact-match assertions don't expect, and an env DSN test — these are pre-existing env-dependent fragilities, not caused by this PR.)🤖 Generated with Claude Code